agentmux_srv\backend\storage/
skills.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent skills — reusable capability records attached to an agent
5//! definition.
6//!
7//! Extracted from `store.rs` in Phase R.4 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::agent_skill_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::AgentSkill` thanks to the re-export.
13
14use rusqlite::params;
15use serde::{Deserialize, Serialize};
16
17use super::error::StoreError;
18use super::store::Store;
19
20/// A reusable skill/capability attached to a agent definition.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct AgentSkill {
23    pub id: String,
24    pub agent_id: String,
25    pub name: String,
26    pub trigger: String,
27    pub skill_type: String,
28    pub description: String,
29    pub content: String,
30    pub created_at: i64,
31}
32
33impl Store {
34    /// List all skills for an agent, ordered by created_at ascending.
35    /// LOCAL channel's skills only — NO cross-channel fallback. Used by the
36    /// def-registry mirror (which always operates on a local agent) and by
37    /// `agent_skill_list`.
38    pub(super) fn agent_skill_list_local(
39        &self,
40        agent_id: &str,
41    ) -> Result<Vec<AgentSkill>, StoreError> {
42        let conn = self.conn.lock().unwrap();
43        let mut stmt = conn.prepare(
44            "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
45             FROM db_agent_skills WHERE agent_id=?1 ORDER BY created_at ASC",
46        )?;
47        let rows = stmt.query_map(params![agent_id], |row| {
48            Ok(AgentSkill {
49                id: row.get(0)?,
50                agent_id: row.get(1)?,
51                name: row.get(2)?,
52                trigger: row.get(3)?,
53                skill_type: row.get(4)?,
54                description: row.get(5)?,
55                content: row.get(6)?,
56                created_at: row.get(7)?,
57            })
58        })?;
59        let mut skills = Vec::new();
60        for row in rows {
61            skills.push(row?);
62        }
63        Ok(skills)
64    }
65
66    pub fn agent_skill_list(&self, agent_id: &str) -> Result<Vec<AgentSkill>, StoreError> {
67        let local = self.agent_skill_list_local(agent_id)?;
68        if !local.is_empty() {
69            return Ok(local);
70        }
71        // Fall back to the global record ONLY for a cross-channel agent
72        // (absent from local SQLite); a locally-known agent with genuinely no
73        // skills must return empty, not resurrect them. (reagent P1 on #1385.)
74        if self.agent_def_exists_local(agent_id)? {
75            return Ok(local);
76        }
77        if let Some(reg) = self.shared_def_registry() {
78            if let Ok(Some(rec)) = reg.get(agent_id) {
79                return Ok(rec
80                    .data
81                    .skills
82                    .iter()
83                    .map(|s| AgentSkill {
84                        id: s.id.clone(),
85                        agent_id: agent_id.to_string(),
86                        name: s.name.clone(),
87                        trigger: s.trigger.clone(),
88                        skill_type: s.skill_type.clone(),
89                        description: s.description.clone(),
90                        content: s.content.clone(),
91                        created_at: rec.data.created_at,
92                    })
93                    .collect());
94            }
95        }
96        Ok(local)
97    }
98
99    /// Get a single skill by id.
100    pub fn agent_skill_get(&self, id: &str) -> Result<Option<AgentSkill>, StoreError> {
101        let conn = self.conn.lock().unwrap();
102        let mut stmt = conn.prepare(
103            "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
104             FROM db_agent_skills WHERE id=?1",
105        )?;
106        let result = stmt.query_row(params![id], |row| {
107            Ok(AgentSkill {
108                id: row.get(0)?,
109                agent_id: row.get(1)?,
110                name: row.get(2)?,
111                trigger: row.get(3)?,
112                skill_type: row.get(4)?,
113                description: row.get(5)?,
114                content: row.get(6)?,
115                created_at: row.get(7)?,
116            })
117        });
118        match result {
119            Ok(skill) => Ok(Some(skill)),
120            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
121            Err(e) => Err(StoreError::Sqlite(e)),
122        }
123    }
124
125    /// Insert a new skill.
126    pub fn agent_skill_insert(&self, skill: &AgentSkill) -> Result<(), StoreError> {
127        {
128            let conn = self.conn.lock().unwrap();
129            conn.execute(
130                "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
131                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
132                params![
133                    skill.id,
134                    skill.agent_id,
135                    skill.name,
136                    skill.trigger,
137                    skill.skill_type,
138                    skill.description,
139                    skill.content,
140                    skill.created_at
141                ],
142            )?;
143        }
144        // Re-mirror the owning definition (no-op for seeded templates). (P0.2b.)
145        self.registry_def_upsert(&skill.agent_id);
146        Ok(())
147    }
148
149    /// Update an existing skill (all fields except id, agent_id, created_at).
150    pub fn agent_skill_update(&self, skill: &AgentSkill) -> Result<bool, StoreError> {
151        let rows = {
152            let conn = self.conn.lock().unwrap();
153            conn.execute(
154                "UPDATE db_agent_skills SET name=?1, trigger=?2, skill_type=?3, description=?4, content=?5
155                 WHERE id=?6",
156                params![
157                    skill.name,
158                    skill.trigger,
159                    skill.skill_type,
160                    skill.description,
161                    skill.content,
162                    skill.id
163                ],
164            )?
165        };
166        // conn dropped — re-mirror the definition (no-op for seeded). (P0.2b.)
167        if rows > 0 {
168            self.registry_def_upsert(&skill.agent_id);
169        }
170        Ok(rows > 0)
171    }
172
173    /// Delete a skill by id. Returns true if a row was deleted.
174    pub fn agent_skill_delete(&self, id: &str) -> Result<bool, StoreError> {
175        let (rows, agent_id) = {
176            let conn = self.conn.lock().unwrap();
177            // Capture the owning agent_id before the delete so we can
178            // re-mirror its definition afterwards.
179            let agent_id: Option<String> = match conn.query_row(
180                "SELECT agent_id FROM db_agent_skills WHERE id=?1",
181                params![id],
182                |row| row.get(0),
183            ) {
184                Ok(v) => Some(v),
185                Err(rusqlite::Error::QueryReturnedNoRows) => None,
186                Err(e) => return Err(StoreError::Sqlite(e)),
187            };
188            let rows = conn.execute("DELETE FROM db_agent_skills WHERE id=?1", params![id])?;
189            (rows, agent_id)
190        };
191        // conn dropped — re-mirror the definition so the global record drops
192        // the removed skill (no-op for seeded templates). (P0.2b.)
193        if rows > 0 {
194            if let Some(aid) = agent_id {
195                self.registry_def_upsert(&aid);
196            }
197        }
198        Ok(rows > 0)
199    }
200}